재시도 가능한 API에 Idempotency-Key 적용하기

재시도 가능한 API에 Idempotency-Key 적용하기

한눈에 보기

Idempotency-Key를 사용자와 작업 범위에 묶어 저장하고 같은 키가 오면 처음 처리 결과를 반환한다.

목차

문제가 되는 상황

클라이언트가 주문 생성 요청을 보낸 뒤 응답을 받기 전에 연결이 끊겼다고 하자. 화면에서는 timeout이지만 서버에서는 주문이 이미 commit되었을 수 있다. 같은 POST를 다시 보내면 두 번째 주문이 만들어질 가능성이 있다.

Idempotency-Key는 네트워크 실패 뒤 같은 논리 작업을 다시 요청할 때 서버가 최초 처리를 식별하도록 돕는다. 단순히 헤더를 DB에 저장하는 것으로 끝나지 않는다. 키의 범위, 요청 본문 비교, 처리 중 동시 요청, 성공 응답 보존, 실패 후 복구를 하나의 계약으로 정해야 한다.

이 글의 위치

멱등적인 HTTP 메서드가 재시도에 중요한 이유가 멱등성의 의미와 HTTP 메서드를 설명했다면, 이 글은 POST 생성 API에 Idempotency-Key를 구현하는 저장 모델과 실패 경계를 다룬다. 예제는 가상의 주문 API다.

키는 논리적인 한 작업을 식별한다

클라이언트는 사용자가 주문 제출을 시작할 때 key를 한 번 생성한다.

async function submitOrder(command: CreateOrderCommand) {
  const idempotencyKey = crypto.randomUUID();

  return retryWithBackoff(() =>
    fetch("/orders", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(command),
    }),
  );
}

네트워크 재시도는 같은 key를 사용한다. 사용자가 이전 주문과 별개의 새 주문을 명시적으로 시작하면 새 key를 만든다. retry 함수 내부에서 시도마다 UUID를 새로 만들면 서버가 같은 작업임을 알 수 없다.

sequenceDiagram
    participant C as Client
    participant A as Orders API
    C->>A: POST /orders, key K1
    A->>A: 주문 O1 생성
    A--xC: 201 응답 유실
    C->>A: 같은 body, 같은 key K1
    A-->>C: 저장한 O1의 201 응답 재사용

키의 scope를 인증 주체와 묶는다

key 문자열 하나만 전역 unique로 사용하면 다른 사용자가 우연히 같은 UUID를 보냈을 때 충돌하거나, 공격자가 관측한 key로 다른 사용자의 결과를 추측할 수 있다. 인증된 주체와 endpoint 또는 작업 종류를 함께 scope로 둔다.

scope = organization_id + operation + idempotency_key
PRIMARY KEY (organization_id, operation, request_key)

주문 생성과 환불 생성이 같은 key를 사용해도 서로 충돌하지 않게 operation을 분리할 수 있다. 반대로 endpoint path 전체를 그대로 저장하면 route alias나 버전 변경 때 같은 논리 작업이 다른 scope로 처리될 수 있으므로 안정적인 업무 operation ID를 정한다.

요청을 처리하기 전에 인증을 완료한다. 인증되지 않은 사용자 입력을 기준으로 key namespace를 만들거나, key만으로 이전 응답에 접근하게 해서는 안 된다.

같은 키와 다른 요청을 거부한다

클라이언트 버그로 같은 key에 금액이나 상품을 바꿔 보낼 수 있다. 서버가 최초 결과를 조용히 반환하면 사용자는 새 본문이 처리되었다고 오해한다. 정규화된 요청의 fingerprint를 저장해 비교한다.

function fingerprintCreateOrder(command: CreateOrderCommand): string {
  const canonical = JSON.stringify({
    currency: command.currency,
    items: [...command.items]
      .map((item) => ({ sku: item.sku, quantity: item.quantity }))
      .sort((a, b) => a.sku.localeCompare(b.sku)),
    shippingAddressId: command.shippingAddressId,
  });

  return createHash("sha256").update(canonical).digest("hex");
}

JSON 원문 hash만 사용하면 의미가 같은 요청도 key 순서나 공백 차이로 달라질 수 있다. 업무 command를 검증·정규화한 뒤 해당 operation에 영향을 주는 필드만 안정적인 순서로 fingerprint한다. 인증 주체처럼 본문 밖의 중요한 문맥도 scope 또는 fingerprint에 포함한다.

같은 key와 다른 fingerprint가 오면 409 Conflict 또는 문서화한 오류를 반환한다.

{
  "status": 409,
  "code": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST",
  "message": "같은 키가 다른 주문 요청에 사용되었습니다."
}

저장할 상태와 응답 결정하기

멱등 레코드는 key 존재 여부만 아니라 처리 생명주기를 표현해야 한다.

CREATE TABLE idempotency_requests (
  owner_id          BIGINT NOT NULL,
  operation         VARCHAR(60) NOT NULL,
  request_key       VARCHAR(100) NOT NULL,
  request_hash      CHAR(64) NOT NULL,
  status            VARCHAR(20) NOT NULL,
  resource_id       VARCHAR(80) NULL,
  response_status   INT NULL,
  response_headers  JSON NULL,
  response_body     JSON NULL,
  started_at        DATETIME NOT NULL,
  completed_at      DATETIME NULL,
  expires_at        DATETIME NOT NULL,
  PRIMARY KEY (owner_id, operation, request_key)
);

상태는 최소한 다음을 구분한다.

상태 의미 중복 요청 처리
processing 최초 요청 진행 중 기다림 또는 409/202
completed 업무와 응답 확정 저장한 결과 반환
failed_retryable 확정 전 일시 실패 정책에 따라 재실행
failed_final 검증 등 영구 실패 동일 실패 반환

성공 응답 전체를 저장할지 resource ID만 저장해 현재 표현을 다시 조회할지 선택한다. 최초와 동일한 응답 계약이 중요하면 status, 필요한 header, body를 snapshot으로 보존한다. 현재 리소스를 다시 읽으면 그사이 상태가 바뀌어 최초 응답과 달라질 수 있다.

Set-Cookie, 추적 ID, 날짜처럼 재사용하면 안 되는 hop별 header를 무작정 모두 저장하지 않는다. API 계약에 필요한 안전한 header만 allowlist로 보존한다.

동시 요청을 unique constraint로 직렬화한다

다음 check-then-insert는 경쟁 조건이 있다.

// 좋지 않은 예
const found = await repository.find(scope, key);
if (!found) {
  await repository.createProcessing(scope, key, requestHash);
  await createOrder(command);
}

두 요청이 동시에 find를 통과할 수 있다. primary key 또는 unique constraint로 선점자를 하나만 만든다.

async function beginRequest(scope, key, requestHash) {
  try {
    await repository.insertProcessing({ scope, key, requestHash });
    return { kind: "acquired" };
  } catch (error) {
    if (!isUniqueViolation(error)) throw error;
    return repository.loadExisting({ scope, key });
  }
}

기존 레코드가 processing이면 무조건 같은 작업을 두 번 실행하지 않는다. 짧게 대기해 완료 결과를 반환하거나 Retry-After와 함께 진행 중 응답을 줄 수 있다.

HTTP/1.1 409 Conflict
Retry-After: 2
Content-Type: application/problem+json

{"code":"IDEMPOTENT_REQUEST_IN_PROGRESS"}

업무 처리와 결과 저장의 원자성

같은 DB에서 주문과 멱등 레코드를 관리한다면 하나의 transaction으로 commit할 수 있다.

const result = await database.transaction(async (tx) => {
  const lease = await tx.idempotency.acquire(scope, key, requestHash);
  if (lease.kind !== "acquired") return handleExisting(lease);

  const order = await tx.orders.create(command);
  const responseSnapshot = { id: order.id, status: order.status };

  await tx.idempotency.complete({
    scope,
    key,
    resourceId: order.id,
    responseStatus: 201,
    responseBody: responseSnapshot,
  });

  return { status: 201, body: responseSnapshot };
});

외부 결제 호출처럼 단일 transaction으로 묶을 수 없으면 실패 창이 생긴다.

외부 결제 성공 → 프로세스 종료 → 멱등 결과 미기록

외부 시스템에도 동일한 안정적 operation ID를 보내고, 호출 결과 조회 API와 reconciliation 작업을 이용해 상태를 복구한다. DB transaction을 길게 열고 외부 API를 기다리는 것은 lock과 timeout 문제를 만들 수 있으므로 outbox·상태 머신·비동기 worker를 검토한다.

실패 상태를 어떻게 재시도할까

모든 실패를 저장해 영원히 같은 오류만 반환하면 일시적인 DB 장애도 복구되지 않는다. 반대로 모든 500에서 레코드를 지우면 실제 업무가 commit된 뒤 응답만 실패한 경우 중복 실행할 수 있다.

실패를 다음처럼 분류한다.

processing 레코드가 영원히 남지 않도록 lease 만료를 둘 수 있지만, 시간이 지났다는 이유만으로 즉시 업무를 재실행하지 않는다. 원래 resource 또는 외부 operation 상태를 먼저 확인한다.

보존 기간과 정리 정책

Idempotency-Key를 영구 보존하면 테이블이 계속 커진다. 클라이언트의 최대 재시도 기간과 업무 중복 위험을 기준으로 TTL을 정하고 API 문서에 공개한다.

Idempotency-Key는 최초 요청 후 24시간 동안 같은 결과를 보장한다.
그 이후 같은 키는 새 요청으로 처리될 수 있다.

결제처럼 늦은 재시도가 위험한 작업은 더 긴 기간 또는 업무 자체의 unique key(merchant_order_id)를 함께 사용한다. 정리 작업은 만료 index를 이용해 작은 batch로 삭제하고 운영 부하를 측정한다.

key가 만료된 뒤 같은 문자열이 재사용될 수 있으므로 client가 충분히 큰 난수를 생성하도록 한다. 순차 숫자나 현재 timestamp만 사용하지 않는다.

클라이언트와 서버 관측성

로그에는 원문 key 전체 대신 hash 또는 앞부분을 마스킹한 식별자를 남길 수 있다.

{
  "event": "idempotency_replay",
  "operation": "create-order",
  "keyFingerprint": "sha256:8b2f...",
  "result": "completed-response-reused",
  "traceId": "trace-example-42"
}

운영 지표는 다음을 구분한다.

재사용률이 갑자기 높으면 client timeout이나 proxy 문제가 있을 수 있고, fingerprint 충돌 증가는 client key 생명주기 버그일 수 있다.

구현 점검 목록

Idempotency-Key API

  • key를 인증 주체와 안정적인 operation scope에 묶었는가?
  • 같은 key의 다른 요청 fingerprint를 거부하는가?
  • unique constraint로 최초 처리자를 하나만 만드는가?
  • processing 중복 요청의 응답 정책이 명확한가?
  • 업무 commit과 결과 저장 사이의 실패를 복구할 수 있는가?
  • TTL과 보장 기간이 client 문서에 있는가?
  • POST 자동 재시도에서 같은 key가 유지되는가?
  • key와 응답의 민감 정보가 로그에 노출되지 않는가?

Idempotency-Key를 사용자와 작업 범위에 묶어 저장하고 같은 키가 오면 처음 처리 결과를 반환한다.

결론

Idempotency-Key는 인증 주체와 논리 operation 범위에서 한 작업을 식별하고, 같은 key와 같은 fingerprint의 재시도에 최초 확정 결과를 반환하는 계약이다. unique constraint로 동시 처리를 직렬화하고 processing·completed·실패 상태를 구분한다. 업무 commit과 결과 저장 사이의 실패 복구, TTL, 응답 snapshot과 관측성까지 설계해야 실제 네트워크 재시도에서 중복을 막을 수 있다.

관련 노트